iT邦幫忙

2023 iThome 鐵人賽

DAY 5
2
Mobile Development

30 天輕鬆學會 Flutter 測試系列 第 5

Day 5 這段程式碼測不了

  • 分享至 

  • xImage
  •  

在昨天的文章中,我們分享了如何透過依賴注入的方式把假的依賴傳入待測物件中,但是如果轉案的程式碼本身沒有這樣的設計,就會很難注入假的依賴,如果我們想改,又可能會牽一髮動全身,容易變成到處都要調整,在 Working Effectively with Legacy Code 這本書中,對於這種沒有測試保護的程式碼就稱為 Legacy Code (遺留代碼),今天就來聊聊如何用調整比較小的方式來解決問題。

在昨天的例子中,UserRepository 的原始設計中並沒有注入 Client 的設計,如果我們像昨天一樣,直接在建構子注入 Client,將會導致所有用到 UserRepository 的地方都要改,如果這是一段 Legacy Code,改起來的風險又更高。

class UserRepository {
  Future<User> get(int userId) async {
    var response = await http.get(Uri.parse("https://jsonplaceholder.typicode.com/users/$userId"));
	
    return User.fromJson(jsonDecode(response.body));
  }
}

那我們就必須想辦法讓修改的範圍小一點,改小一點,影響範圍可能就會小一些,更重要的是,我們也能針對這個修改的範圍加上測試。

多個建構子

首先,我們第一個處理方法就是使用多建構子,在原本 Legacy Code 中,使用 UserRepository 的物件使用前可能會直接使用無參數的建構子來建立 UserRepository,在我們修改 Legacy Code 時,如果想要小範圍的修改,那我們勢必就得維持這個無參數的建構子。

class UserProfileController {
	final UserRepository _userRepository = UserRepository();
	
	Future<User> readName(int userId) {
		return (await userRepository.get(userId)).name;
	}
}

讓我們來修改一下 UserRepository,首先我們加入一個注入 Client 的建構子,同時也維持原本的無參數建構子,但是需要注意的是這個無參數的建構子會呼叫注入 Client 的建構子,然後建立 Client 並注入。

class UserRepository {
  final Client _client;

  UserRepository() : this.create(Client());

  UserRepository.create(Client client) : _client = client;

  Future<User> get(int userId) async {
    var response = await client.get(Uri.parse("https://jsonplaceholder.typicode.com/users/$userId"));
    return User.fromJson(jsonDecode(response.body));
  }
}

修改完之後,我們除了先前新增的傳入 Client 的建構子之外,又新增了另一個無參數建構子,讓原本使用 UserRepository 無參數建構子的人,依舊可以繼續使用。在無參數建構子的實作中,它建立新的 Client 並傳入另外一個建構子完成類別建構。

內在行為大不同

生命週期管理失效

使用多建構子來處理非常簡單,大多時候也不會有什麼大問題,但其實這樣的改法稍微地改變了原本的行為。那我們改變了什麼呢?簡單來說就是 Client 的生命週期,在原本使用 http 的邏輯中,http 會自動地去建立 Client,並且在 Client 用完之後關閉並釋放資源。

Future<Response> get(Uri url, {Map<String, String>? headers}) =>
    _withClient((client) => client.get(url, headers: headers));

Future<T> _withClient<T>(Future<T> Function(Client) fn) async {
  var client = Client();
  try {
    return await fn(client);
  } finally {
    client.close();
  }
}

但是當我們為了測試修改程式碼之後,由於我們自己建立的 Client 物件,Client 的物件再也不是由 http 套件控管,也就不會在呼叫 API 完成後關閉,可能會造成資源無法被釋放。

建構物件消耗資源巨大

在原本的 UserRepository 中,只有在真的要打 API 時,http 才會建立 Client。假設建立 Client 十分消耗資源,可能需要花費幾百毫秒,甚至幾千毫秒。那我們把建立 Client 移動到建構子,也會造成 Client 在一開始就被建立並佔據資源,如果後續程式碼沒有真的使用到 Client,就變成了浪費。

Extrat and Override

為了解決上述的問題,我們還可以使用另外一種技巧:Extract and Override。在 Dart 中,只要該類別不是 Sealed Class,我們就可以寫另外一個類別繼承它,並且複寫任意方法。 透過這個特性,我們可以使用 Extract and Override 來新增一個測試用的類別,讓我們先看看原本的 UserRepository。

/// 部分 http.dart 原始碼

class UserRepository {
  Future<User> get(int userId) async {
  var response = await http.get(Uri.parse("https://jsonplaceholder.typicode.com/users/$userId"));
    return User.fromJson(jsonDecode(response.body));
  }
}

首先,我們先透過 Extract Method 的技巧把 http.get 這個外部依賴抽成另外一個方法。

class UserRepository {
  Future<User> get(int userId) async {
    var response = await httpGet(Uri.parse("https://jsonplaceholder.typicode.com/users/$userId"));
    return User.fromJson(jsonDecode(response.body));
  }

  Future<http.Response> httpGet(Uri uri) => http.get(uri);
}

接著我們就能新增一個測試用的 UserRepository,然後把 httpGet 複寫掉,讓他回傳我們想要的 Response。

class TestUserRepository extends UserRepository {
  final http.Response response;

  TestUserRepository(this.response);

  @override
  Future<http.Response> httpGet(Uri uri) async => response;
}

最後我們就能拿著這個 TestUserRepository 完成我們的單元測試了。

main() {
  test("get user ok from api", () async {
    var userRepository = TestUserRepository(Response("{\"id\":1, \"name\": \"Tom\"}", 200));

    var user = await userRepository.get(1);

    expect(user, User(id: 1, name: "Tom"));
  });
}

昨天的測試一樣,測試並不直接跟遠端伺服器互動,而是在本地就能完成,與昨天的測試差別在於,我們是透過繼承與複寫的方式處理的外部依賴。這個技巧再處理 Legacy Code 的時候十分有用,尤其我們並不想修改這個類別的介面更有效。有興趣的觀眾朋友可以從 Dartpad 執行例子

不好測,也意味著壞味道

如果今天我們的程式碼是 Legacy Code,存在無法測試的問題,使得我們必須使用 Extract And Override 來解決問題,這是比較難避免的。但是如果我們新寫的程式碼也必須使用 Extract And Override 時,我們就必須回頭思考設計是否有問題。

大多時候,不好測試也意味著設計可能存在問題,以剛才程式碼為例,UserRepository 違反了 SOLID 中的依賴反轉原則, UserRepository 與 http 僅僅耦合在了一起,當未來我們想把儲存 User 的方法從遠端 Server 換成 Local Storage 時,耦合的問題就會讓我們一個頭兩個大,因為修改的範圍可能很大。

關於更多處理 Legacy Code 的技巧,有興趣的觀眾朋友有可以參考 Working Effectively with Legacy Code : 管理、修改、重構遺留程式碼的藝術

小結

雖然使用多建構子的方式會稍微改變程式的行為,但其實處理得當的話,其實並不會造成太大的問題,這樣的作法也比較符合設計原則,往後處理更上層的 Legacy Code 之後,就有機會把無參數建構子拿掉。

但是總有一些特別情況,我們幾乎無法做任何調整,比如說 API 已經開放給別人使用,或者我們一點都不想暴露測試用的 API 使用端,那至少我們還能選擇 Extract and Override 的方式來測試,避免我們先入因 Legacy Code 而無法測試的窘境。


上一篇
Day 4 測試替身與依賴注入
下一篇
Day 6 不改變狀態也不回傳,那我怎麼測試?
系列文
30 天輕鬆學會 Flutter 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言